#!/usr/bin/lua

releasever = nil
solvcachedir = '/var/cache/solv'

posix = require("posix")
posix.sys.stat = require("posix.sys.stat")
require("solv")

-- helpers

function parse_ini_file(fn)
  local sections = {}
  for line in io.open(fn, 'r'):lines() do
    local match = line:match('^%[([^%[%]]+)%]$')
    if match then
      section = match
      sections[section] = sections[section] or {}
    else
      local param, value = line:match('^([%w|_]+)%s-=%s-(.+)$')
      if param then sections[section][param] = value end
    end
  end
  return sections
end

function die(str)
  io.stdout:write(str.."\n")
  os.exit(1)
end

function isdir(path)
  local st = posix.sys.stat.stat(path)
  return st and posix.S_ISDIR(st.st_mode) ~= 0
end

function lsdir(path)
  local content = posix.dirent.dir(path) or {}
  table.sort(content)
  return content
end

function load_stub(repodata)
  local repo = repodata.repo.appdata
  if repo then
    return repo:load_ext(repodata)
  end
  return false
end

-- generic repo implementation

Repo = {}

function Repo.calc_cookie_filename(filename)
  chksum = solv.Chksum(solv.REPOKEY_TYPE_SHA256)
  chksum:add("1.1")
  chksum:add_stat(filename)
  return chksum:raw()
end

function Repo.calc_cookie_fp(fp)
  chksum = solv.Chksum(solv.REPOKEY_TYPE_SHA256)
  chksum:add("1.1")
  chksum:add_fp(fp)
  return chksum:raw()
end

function Repo.calc_cookie_ext(f, cookie)
  chksum = solv.Chksum(solv.REPOKEY_TYPE_SHA256)
  chksum:add("1.1")
  chksum:add(cookie)
  chksum:add_fstat(f:fileno())
  return chksum:raw()
end

function Repo:download(file, uncompress, chksum, markincomplete)
  if not self.baseurl then
    io.stdout:write(self.alias..": no baseurl\n")
    return nil
  end
  local url = self.baseurl:gsub('/$', '')..'/'..file
  local tmpfile = io.tmpfile()
  local fd = posix.stdio.fileno(tmpfile)
  local status, reason = posix.spawn({'curl', '-f', '-s', '-L', '-o', '/dev/fd/'..fd, '--', url})
  if posix.unistd.lseek(fd, 0, posix.unistd.SEEK_END) == 0 and (status == 0 or not chksum) then
    tmpfile:close()
    return nil
  end
  posix.unistd.lseek(fd, 0, posix.unistd.SEEK_SET);
  if status ~= 0 then
    print(file..": download error "..status.."\n");
    tmpfile:close()
    return nil
  end
  if chksum then
    local fchksum = solv.Chksum(chksum.type);
    fchksum:add_fd(fd)
    if fchksum ~= chksum then
      print(file..": checksum error")
      if markincomplete then self.incomplete = 1 end
      return nil
    end
  end
  local ret
  if uncompress then
    ret = solv.xfopen_fd(file, fd);
  else
    ret = solv.xfopen_fd(nil, fd);
  end
  tmpfile:close()
  return ret
end

function Repo:load(pool)
  self.handle = pool:add_repo(self.alias)
  self.handle.appdata = self
  self.handle.priority = 99 - self.priority
  if self:usecachedrepo() then
    io.stdout:write("repo '"..self.alias.."': cached\n")
    return true
  end
  return false
end

function Repo:cachepath(ext)
  local path = self.alias:gsub('^%.', '_');
  if ext then
    path = path .. '_' .. ext ..'.solvx'
  else
    path = path ..'.solv'
  end
  return '/var/cache/solv/' .. path:gsub('/', '_');
end
  
function Repo:usecachedrepo(ext, mark)
  local cookie
  if ext then
    cookie = self.extcookie
  else
    cookie = self.cookie
  end
  local repopath = self:cachepath(ext)
  local f = io.open(repopath, 'rb')
  if not f then
    return false
  end
  f:seek('end', -32)
  local fcookie = f:read(32)
  if not fcookie or fcookie:len() ~= 32 then
    f:close()
    return false
  end
  if cookie and cookie ~= fcookie then
    f:close()
    return false
  end
  local fextcookie
  if self.type ~= 'system' and not ext then
    f:seek('end', -64)
    fextcookie = f:read(32)
    if not fcookie or fcookie:len() ~= 32 then
      f:close()
      return false
    end
  end
  f:seek('set')
  posix.unistd.lseek(posix.stdio.fileno(f), 0, posix.unistd.SEEK_SET)
  local ff = solv.xfopen_fd('', posix.stdio.fileno(f))
  f:close()
  local flags = 0
  if ext then
    flags = flags | solv.Repo.REPO_USE_LOADING|solv.Repo.REPO_EXTEND_SOLVABLES
    if ext ~= 'DL' then flags = flags | solv.Repo.REPO_LOCALPOOL end
  end
  if not self.handle:add_solv(ff, flags) then
    ff:close()
    return false
  end
  if self.type ~= 'system' and not ext then
    self.cookie = fcookie
    self.extcookie = fextcookie
  end
  if mark then
    posix.utime(repopath)
  end
  return true
end

function Repo:writecachedrepo(ext, repodata)
  if self.incomplete then return end
  if not isdir(solvcachedir) then
    posix.sys.stat.mkdir(solvcachedir, 7 * 64 + 5 * 8 + 5)
  end
  local fd, tmpname = posix.stdlib.mkstemp(solvcachedir..'/.newsolv-XXXXXX')
  if not fd then
    return
  end
  local ff =  solv.xfopen_fd('', fd)
  if not repodata then
    self.handle:write(ff)
  elseif ext then
    repodata:write(ff)
  else
    self.handle:write_first_repodata(ff)
  end
  ff:flush()
  if self.type ~= 'system' and not ext then
    self.extcookie = self.extcookie or Repo.calc_cookie_ext(ff, self.cookie)
    ff:write(self.extcookie)
  end
  if not ext then
    ff:write(self.cookie)
  else
    ff:write(self.extcookie)
  end
  ff:close()
  os.rename(tmpname, self:cachepath(ext))
end

function Repo:add_ext_keys(ext, repodata, handle)
  if ext == 'DL' then
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.REPOSITORY_DELTAINFO)
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.REPOKEY_TYPE_FLEXARRAY)
  elseif ext == 'DU' then
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.SOLVABLE_DISKUSAGE)
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.REPOKEY_TYPE_DIRNUMNUMARRAY)
  elseif ext == 'FL' then
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.SOLVABLE_FILELIST)
    repodata:add_idarray(handle, solv.REPOSITORY_KEYS, solv.REPOKEY_TYPE_DIRSTRARRAY)
  end
end

-- rpmmd repo implementation

Repo_rpmmd = {}
setmetatable(Repo_rpmmd, {__index = Repo })

function Repo_rpmmd:find(what)
  local di = self.handle:Dataiterator_meta(solv.REPOSITORY_REPOMD_TYPE, what, solv.Dataiterator.SEARCH_STRING)
  di:prepend_keyname(solv.REPOSITORY_REPOMD)
  for d in di do
    local dp = d:parentpos()
    local filename = dp:lookup_str(solv.REPOSITORY_REPOMD_LOCATION)
    if filename then
      local chksum = dp:lookup_checksum(solv.REPOSITORY_REPOMD_CHECKSUM)
      if not chksum then
	print("no "..filename.." file checksum!\n")
	return
      end
      return filename, chksum
    end
  end
end

function Repo_rpmmd:add_ext(repodata, what, ext)
  local filename, filechksum = self:find(what)
  if not filename and what == 'deltainfo' then
    filename, filechksum = self:find('prestodelta')
  end
  if filename then
    local handle = repodata:new_handle()
    repodata:set_poolstr(handle, solv.REPOSITORY_REPOMD_TYPE, what)
    repodata:set_str(handle, solv.REPOSITORY_REPOMD_LOCATION, filename)
    repodata:set_checksum(handle, solv.REPOSITORY_REPOMD_CHECKSUM, filechksum)
    self:add_ext_keys(ext, repodata, handle)
    repodata:add_flexarray(solv.SOLVID_META, solv.REPOSITORY_EXTERNAL, handle)
  end
end

function Repo_rpmmd:add_exts()
  repodata = self.handle:add_repodata(0)
  repodata:extend_to_repo()
  self:add_ext(repodata, 'deltainfo', 'DL')
  self:add_ext(repodata, 'filelists', 'FL')
  repodata:internalize()
end

function Repo_rpmmd:load(pool)
  if Repo.load(self, pool) then return true end
  io.stdout:write("rpmmd repo '"..self.alias.."': ")
  local f = self:download("repodata/repomd.xml");
  if not f then
    print("no repomd.xml file, skipped");
    self.handle:free(true)
    self.handle = nil
    return false
  end
  self.cookie = self.calc_cookie_fp(f)
  if self:usecachedrepo(nil, True) then
    print("cached")
    return true
  end
  self.handle:add_repomdxml(f, 0)
  f:close()
  print("fetching")
  local filename, filechksum
  filename, filechksum = self:find('primary')
  if filename then
    f = self:download(filename, true, filechksum, true)
    if f then
      self.handle:add_rpmmd(f, nil, 0)
      f:close()
    end
    if self.incomplete then return false end
  end
  filename, filechksum = self:find('updateinfo')
  if filename then
    f = self:download(filename, true, filechksum, true)
    if f then
      self.handle:add_updateinfoxml(f, 0)
      f:close()
    end
  end
  self:add_exts()
  self:writecachedrepo()
  self.handle:create_stubs()
  return true
end

function Repo_rpmmd:load_ext(repodata)
  local repomdtype = repodata:lookup_str(solv.SOLVID_META, solv.REPOSITORY_REPOMD_TYPE)
  local ext
  if repomdtype == 'filelists' then
    ext = 'FL'
  elseif repomdtype == 'deltainfo' then
    ext = 'DL'
  else
    return false
  end
  io.stdout:write("["..self.alias..":"..ext..": ")
  if self:usecachedrepo(ext) then
    io.stdout:write("cached]\n")
    return true
  end
  io.stdout:write("fetching]\n")
  local filename = repodata:lookup_str(solv.SOLVID_META, solv.REPOSITORY_REPOMD_LOCATION)
  local filechksum = repodata:lookup_str(solv.SOLVID_META, solv.REPOSITORY_REPOMD_CHECKSUM)
  local f = self:download(filename, true, filechksum)
  if not f then
    return false
  end
  if ext == 'FL' then
    self.handle:add_rpmmd(f, 'FL', solv.Repo.REPO_USE_LOADING|solv.Repo.REPO_EXTEND_SOLVABLES|solv.Repo.REPO_LOCALPOOL)
  elseif ext == 'DL' then
    self.handle:add_deltainfoxml(f, solv.Repo.REPO_USE_LOADING)
  end
  self:writecachedrepo(ext, repodata)
  return true
end

-- susetags repo implementation

Repo_susetags = {}
setmetatable(Repo_susetags, {__index = Repo })

-- unknown repo implementation

Repo_unknown = {}
setmetatable(Repo_unknown, {__index = Repo })

function Repo_unknown:load()
  print("unsupported repo '"..self.alias.."': skipped")
  return false
end

-- system repo implementation

Repo_system = {}
setmetatable(Repo_system, {__index = Repo })

function Repo_system:load(pool)
  self.handle = pool:add_repo(self.alias)
  self.handle.appdata = self
  pool.installed = self.handle
  io.stdout:write("rpm database: ")
  self.cookie = Repo.calc_cookie_filename("/var/lib/rpm/Packages")
  if self:usecachedrepo() then
    print("cached")
    return true
  end
  io.stdout:write("reading\n")
  local oldf = solv.xfopen(self:cachepath())
  self.handle:add_rpmdb_reffp(oldf, solv.Repo.REPO_REUSE_REPODATA)
  self:writecachedrepo()
end

-- main

cmd = arg[1]
if not cmd then die("Usage: luasolv COMMAND [ARGS]") end
table.remove(arg, 1)

cmdabbrev = { ['ls']='list'; ['in']='install'; ['rm']='erase'; ['ve']='verify'; ['se']='search'  }
cmd = cmdabbrev[cmd] or cmd

cmdactions = { ['install']=solv.Job.SOLVER_INSTALL; ['erase']=solv.Job.SOLVER_ERASE; ['up']=solv.Job.SOLVER_UPDATE; ['dup']=solv.Job.SOLVER_DISTUPGRADE; ['verify']=solv.Job.SOLVER_VERIFY; ['list']=0; ['info']=0 }

repos = {}
reposdir = {}
if isdir('/etc/zypp/repos.d') then
  table.insert(reposdir, '/etc/zypp/repos.d')
elseif isdir('/etc/yum/repos.d') then
  table.insert(reposdir, '/etc/yum/repos.d')
end
for _, repodir in ipairs(reposdir) do
  for _, e in ipairs(lsdir(repodir)) do
    if e:sub(-5) == '.repo' then
      sections = parse_ini_file(repodir..'/'..e)
      for alias, section in pairs(sections) do
	repo = { ['alias']=alias; ['enabled']=false; ['priority']=99; ['autorefresh']=true; ['type']='rpm-md'; ['metadata_expire']=900 }
	if section.name then repo.name = section.name end
	if section.type then repo.type = section.type end
	if section.baseurl then repo.baseurl = section.baseurl end
	if section.metadata_expire then repo.metadata_expire = tonumber(section.metadata_expire) end
	if section.enabled then repo.enabled = tonumber(section.enabled) ~= 0 end
	if section.autorefresh then repo.autorefresh = tonumber(section.autorefresh) ~= 0 end
	if section.gpgcheck then repo.gpgcheck = tonumber(section.gpgcheck) ~= 0 end
	if section.priority then repo.priority = tonumber(section.priority) end
	if repo.baseurl and releasever then repo.baseurl = repo.baseurl:gsub('$releasever', releasever) end
	if repo.type == 'rpm-md' then
	  setmetatable(repo , {__index = Repo_rpmmd })
	elseif repo['type'] == 'yast2' then
	  setmetatable(repo , {__index = Repo_susetags })
	else
	  setmetatable(repo , {__index = Repo_unknown })
	end
	table.insert(repos, repo)
      end
    end
  end
end

local pool = solv.Pool()
pool:setarch()
pool:set_loadcallback(load_stub)

sysrepo = { ['alias']='@System', ['type']='system' }
setmetatable(sysrepo , {__index = Repo_system })
sysrepo:load(pool)
for _, repo in ipairs(repos) do
  if repo.enabled then
    repo:load(pool)
  end
end

if cmd == 'search' then
  pool:createwhatprovides()
  sel = pool:Selection()
  di = pool:Dataiterator(solv.SOLVABLE_NAME, arg[1], solv.Dataiterator.SEARCH_SUBSTRING | solv.Dataiterator.SEARCH_NOCASE)
  for d in di do
    sel:add_raw(solv.Job.SOLVER_SOLVABLE, d.solvid)
  end
  for _, s in ipairs(sel:solvables()) do
    print('  - '..tostring(s)..' ['..s.repo.name..']: '..s:lookup_str(solv.SOLVABLE_SUMMARY))
  end
  os.exit(0)
end

if not cmdactions[cmd] then die(("unknown command '%s'"):format(cmd)) end

pool:addfileprovides()
pool:createwhatprovides()

jobs = {}
for _, a in ipairs(arg) do
  flags = solv.Selection.SELECTION_NAME | solv.Selection.SELECTION_PROVIDES | solv.Selection.SELECTION_GLOB
  flags = flags | solv.Selection.SELECTION_CANON | solv.Selection.SELECTION_DOTARCH | solv.Selection.SELECTION_REL
  if a:sub(1, 1) == '/' then
    flags = flags | solv.Selection.SELECTION_FILELIST
    if cmd == 'erase' then
      flags = flags | solv.Selection.SELECTION_INSTALLED_ONLY
    end
  end
  local sel = pool:select(a, flags)
  if sel:isempty() then
    sel = pool:select(a, flags | solv.Selection.SELECTION_NOCASE)
    if not sel:isempty() then
      print("[ignoring case for '"..a.."']")
    end
  end
  if (sel.flags & solv.Selection.SELECTION_FILELIST) ~= 0 then
    print("[using file list match for '"..a.."']")
  end
  if (sel.flags & solv.Selection.SELECTION_PROVIDES) ~= 0 then
    print("[using capability match for '"..a.."']")
  end
  for _, j in ipairs(sel:jobs(cmdactions[cmd])) do
     table.insert(jobs, j)
  end
end

if #jobs == 0 and (cmd == 'up' or cmd == 'dup' or cmd == 'verify') then
  for _, j in ipairs(pool:Selection_all():jobs(cmdactions[cmd])) do
     table.insert(jobs, j)
  end
end

if #jobs == 0 then die("no package matched.") end

if cmd == 'list' or cmd == 'info' then
  for _, job in ipairs(jobs) do
    for _, s in ipairs(job:solvables()) do
      if cmd == 'info' then
	local str
        print('Name:        '..tostring(s))
        print('Repo:        '..s.repo.name)
        print('Summary:     '..s:lookup_str(solv.SOLVABLE_SUMMARY))
        str = s:lookup_str(solv.SOLVABLE_URL)
	if str then print('Url:         '..str) end
        str = s:lookup_str(solv.SOLVABLE_LICENSE)
	if str then print('License:     '..str) end
        print("Description\n"..s:lookup_str(solv.SOLVABLE_DESCRIPTION))
        print('')
      else
        print('  - '..tostring(s)..' ['..s.repo.name..']')
        print('    '..s:lookup_str(solv.SOLVABLE_SUMMARY))
      end
    end
  end
  os.exit(0)
end

local solver = pool:Solver()
if cmd == 'erase' then
  solver:set_flag(solv.Solver.SOLVER_FLAG_ALLOW_UNINSTALL, 1)
end

while true do
  local problems = solver:solve(jobs)
  if #problems == 0 then break end
  for _, problem in ipairs(problems) do
    print(("Problem %d/%d:"):format(problem.id, #problems))
        print(problem)
        local solutions = problem:solutions()
        for _, solution in ipairs(solutions) do
            print(("  Solution %d:"):format(solution.id))
            local elements = solution:elements(true)
            for _, element in ipairs(elements) do
                print(("  - %s"):format(element))
	    end
            print('')
	end
  end
  os.exit(1)
end

local trans = solver:transaction()
if trans:isempty() then
  print("Nothing to do.")
  os.exit(0)
end
print('')
print("Transaction summary:")
print('')
for _, cl in ipairs(trans:classify(solv.Transaction.SOLVER_TRANSACTION_SHOW_OBSOLETES | solv.Transaction.SOLVER_TRANSACTION_OBSOLETE_IS_UPGRADE)) do
  if cl.type == solv.Transaction.SOLVER_TRANSACTION_ERASE then
    print(("%d erased packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_INSTALL then
    print(("%d installed packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_REINSTALLED then
    print(("%d reinstalled packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_DOWNGRADED then
    print(("%d downgraded packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_CHANGED then
    print(("%d changed packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_UPGRADED then
    print(("%d upgraded packages:"):format(cl.count))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_VENDORCHANGE then
    print(("%d vendor changes from '%s' to '%s':"):format(cl.count, cl.fromstr, cl.tostr))
  elseif cl.type == solv.Transaction.SOLVER_TRANSACTION_ARCHCHANGE then
    print(("%d arch changes from '%s' to '%s':"):format(cl.count, cl.fromstr, cl.tostr))
  else
    cl = nil
  end
  if cl then
    for _, p in ipairs(cl:solvables()) do
      if cl.type == solv.Transaction.SOLVER_TRANSACTION_UPGRADED or cl.type == solv.Transaction.SOLVER_TRANSACTION_DOWNGRADED then
        print(("  - %s -> %s"):format(p, trans:othersolvable(p)))
      else
        print(("  - %s"):format(p))
      end
    end
    print('')
  end
end
print(("install size change: %d K"):format(trans:calc_installsizechange()))
print('')

